package com.googlecode.cswish.util;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.StringWriter;
import java.io.Writer;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLConnection;
import java.net.URLStreamHandler;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;

import javax.annotation.Resource;

import org.apache.log4j.Logger;
import org.htmlcleaner.BaseToken;
import org.htmlcleaner.CleanerProperties;
import org.htmlcleaner.ContentNode;
import org.htmlcleaner.HtmlCleaner;
import org.htmlcleaner.ITagInfoProvider;
import org.htmlcleaner.TagInfo;
import org.htmlcleaner.TagNode;
import org.htmlcleaner.XPatherException;
import org.htmlcleaner.XmlSerializer;

import com.googlecode.cswish.model.TagScope;

/**
 * Find and replace page element (one page use one instance)
 * 
 * @author Jerry.Feng 2008-11-18
 * 
 */
@Resource(shareable=false)
public class HtmlCleanerUtil {
	
	private static final Logger logger = Logger.getLogger(HtmlCleanerUtil.class);
	
	private HtmlCleaner cleaner;

	private TagNode htmlNode;

	private PartXmlSerializer serializer;
	
	// ////////////////////////// Cache the search result //////////////////////
	private long cacheTime;
	private String context;

	// ////////////////////////// Cache the search result //////////////////////
	private String textCache;
	private int[] nodeIndexs;
	private TagNode[] nodes;
	
	URLStreamHandler handler = new URLStreamHandler() {
		@Override
		protected URLConnection openConnection(URL url)
				throws IOException {
			HttpURLConnection httpConn = (HttpURLConnection) new URL(url.toString()).openConnection();
			httpConn.setReadTimeout(15000);		 
			httpConn.setRequestMethod("GET");
			httpConn.addRequestProperty("Cookie", "preferredLanguage=en-US");
			httpConn.setRequestProperty("User-Agent", "Mozilla/4.0 (compatible; MSIE 6.0; Windows 2000)");
			return httpConn;
		}
	};
	
	public void setHandler(URLStreamHandler handler) {
		this.handler = handler;
	}
	
	public HtmlCleanerUtil() {
		cleaner = new HtmlCleaner();
		CleanerProperties props = cleaner.getProperties();
		props.setOmitXmlDeclaration(true);
		props.setOmitDoctypeDeclaration(false);
		props.setAdvancedXmlEscape(false);
		props.setUseCdataForScriptAndStyle(false);
		props.setUseEmptyElementTags(false);
		
		// forbid cleaner tag do the code adjustment
		ITagInfoProvider tagInfoProvider = cleaner.getTagInfoProvider();
		TagInfo tagInfo = tagInfoProvider.getTagInfo("link");
		// 0 -- HEAD_AND_BODY
		tagInfo.setBelongsTo(0);
		tagInfo = tagInfoProvider.getTagInfo("style");
		tagInfo.setBelongsTo(0);
		
		serializer = new PartXmlSerializer(props);
	}

	public void include(String path) {
		include(path,"UTF-8");
	}
	
	public void include(String path,String charset) {
		try {
			URL url = this.getClass().getResource(path);
			String protocol = null;
			if (url != null) {
				protocol = url.getProtocol();
			} else {
				url = new URL(path);
			}
			if ("file".equals(protocol)) {
				File file = new File(url.getFile());
				if (file.exists()) {
					long time = file.lastModified();
					if (time != cacheTime || context == null) {
						BufferedReader br = new BufferedReader(new InputStreamReader(new FileInputStream(file)));
						StringBuilder sb = new StringBuilder();
						String data = null;
						while((data = br.readLine())!=null) {
							sb.append(data).append("\n");
						}
						context = sb.toString();
						cacheTime = time;
					}
					
					htmlNode = cleaner.clean(context);
				} 
			} else if ("jar".equals(protocol)) {
				htmlNode = cleaner.clean(this.getClass().getResourceAsStream(path));
			} else {
				htmlNode = cleaner.clean(new URL(url, "", handler), charset);
			}
		} catch (Exception ex) {
			logger.error(ex);
		}
	}
	
	public TagNode find(String xpath) {
		Object[] elements = findAll(xpath);
		
		if (elements != null && elements.length > 0 
				&& elements[0] instanceof TagNode) {
			return (TagNode) elements[0];
		} else {
			return null;
		}
	}
	
	/**
	 * Search xpath according to the following rule:<br>
	 * 1. Search by xpath<br>
	 * 2. Search by element name<br>
	 * 3. Search by element id<br>
	 * 4. Search by text<br>
	 * 
	 * @param xpath
	 * @return
	 */
	public Object[] findAll(String xpath) {
		Object[] elements = null;
		try {
			// add the missed path automatically
			boolean searchInHeadAndBody = false;
			if (xpath.length() > 2) {
				// it's absolute path
				if (xpath.charAt(0) == '/' && xpath.charAt(1) != '/') {
					if (!xpath.startsWith("/head/") && !xpath.startsWith("/body/")) {
						searchInHeadAndBody = true;
					}
				}
			}
			if (searchInHeadAndBody) {
				elements = htmlNode.evaluateXPath("/body" + xpath);
				if (elements == null || elements.length == 0) {
					elements = htmlNode.evaluateXPath("/head" + xpath);
				}
			} else {
				elements = htmlNode.evaluateXPath(xpath);
			}

			// 1. search by xpath
			if (elements == null || elements.length == 0) {

				// 2. search by name
				elements = htmlNode.getElementsByAttValue("name", xpath, true,
						true);
				if (elements == null || elements.length == 0) {
					// 3. search by id
					elements = htmlNode.getElementsByAttValue("id", xpath, true,
							true);
				}
			}

			// still no found
			if (elements == null || elements.length == 0) {
				if (textCache == null) {
					initText();
				}

				int index = textCache.indexOf(xpath);
				if (index > 0) {
					TagNode node = getElementByIndex(index, xpath.length());
					if (node != null) {
						elements = new Object[] {node};
					}
				}
			}
		} catch (XPatherException e) {
			logger.error("Wrong xpaht format:" + xpath);
		}
		return elements;
	}
	
	public Map<String, TagNode> listAllNameAndId() {
		Map<String, TagNode> nodes = new HashMap<String, TagNode>();
		
		TagNode[] nameNodes = htmlNode.getElementsHavingAttribute("name", true);
		for (TagNode tagNode : nameNodes) {
			nodes.put(tagNode.getAttributeByName("name"), tagNode);
		}
		TagNode[] idNodes = htmlNode.getElementsHavingAttribute("id", true);
		for (TagNode tagNode : idNodes) {
			nodes.put(tagNode.getAttributeByName("id"), tagNode);
		}
		return nodes;
	}

	private TagNode getElementByIndex(int searchIndex, int contextLength) {
		// 1. find begin node
		int beginNodeIndex = 0;
		// then nodeIndexs.length >= 2, such as [0, 100]
		// begin from 0, end with string length
		// don't check beginNodeIndex < nodeIndexs.length, because the result
		// can be found
		for (; searchIndex >= nodeIndexs[beginNodeIndex]; ++beginNodeIndex)
			;
		beginNodeIndex--;

		// 2. find the end node
		int endSearchIndex = searchIndex + contextLength;
		int endNodeIndex = beginNodeIndex;
		// don't check endNodeIndex < nodeIndexs.length, because the result can
		// be found
		for (; endSearchIndex >= nodeIndexs[endNodeIndex]; ++endNodeIndex)
			;
		endNodeIndex--;

		if (beginNodeIndex == endNodeIndex) {
			return nodes[beginNodeIndex];
		} else {
			// find the same parent
			TagNode beginNode = nodes[beginNodeIndex];
			TagNode endNode = nodes[endNodeIndex];
			return getCommonParent(beginNode, endNode);
		}
	}
	
//	private TagNode getCommonParent(Object[] elements) {
//		if (elements.length == 0) {
//			return null;
//		} else if (elements.length == 1) {
//			if (elements[0] instanceof TagNode) {
//				return (TagNode) elements[0];
//			} else {
//				return null;
//			}
//		} else {
//			int i = 0;
//			TagNode preElement = null;
//			for (; i < elements.length; i++) {
//				if (preElement instanceof TagNode) {
//					preElement = (TagNode) elements[i];
//					break;
//				}
//			}
//			
//			for (i++; i < elements.length; i++) {
//				Object curElement = elements[i];
//				
//				if (!(curElement instanceof TagNode)) {
//					continue;
//				}
//				preElement = getCommonParent(preElement, (TagNode)curElement);
//			}
//			return (TagNode) preElement;
//		}
//	}
	
	private TagNode getCommonParent(TagNode node1, TagNode node2) {
		for (; node1 != null; node1 = node1.getParent()) {
			for (TagNode node2Copy = node2; node2Copy != null; node2Copy = node2Copy.getParent()) {
				if (node1 == node2Copy) {
					return node1;
				}
			}
		}
		return node1;
	}

	private void initText() {
		StringBuilder text = new StringBuilder();
		List<TagNode> nodes = new ArrayList<TagNode>();
		List<Integer> nodeIndexs = new ArrayList<Integer>();

		initText(text, nodes, nodeIndexs, htmlNode);
		// add a virtual index, simplify the search logic - see
		// 'getElementByIndex'
		nodeIndexs.add(text.length() + 1);

		this.textCache = text.toString();
		this.nodes = new TagNode[nodes.size()];
		this.nodeIndexs = new int[nodeIndexs.size()];
		nodes.toArray(this.nodes);
		for (int i = 0, len = nodeIndexs.size(); i < len; i++) {
			Integer nodeIndex = nodeIndexs.get(i);
			this.nodeIndexs[i] = nodeIndex;
		}
	}

	private void initText(StringBuilder text, List<TagNode> nodes,
			List<Integer> nodeIndexs, TagNode tagNode) {
		List<Object> children = tagNode.getChildren();
		for (int i = 0; i < children.size(); i++) {
			Object item = children.get(i);
			if (item instanceof ContentNode) {
				int oldSize = text.length();
				appendWords(text, ((ContentNode) item).getContent().toString());
				int newSize = text.length();

				if (newSize > oldSize) {
					if (nodes.size() == 0
							|| nodes.get(nodes.size() - 1) != tagNode) {
						nodes.add(tagNode);
						nodeIndexs.add(oldSize + 1);
					}
				}
			} else if (item instanceof TagNode) {
				initText(text, nodes, nodeIndexs, (TagNode) item);
			}
		}
	}

	// separated by space
	private void appendWords(StringBuilder sb, String text) {
		boolean insertSeparate = true;
		for (int i = 0; i < text.length(); i++) {
			char c = text.charAt(i);
			if (Character.isWhitespace(c) || c == '\r' || c == '\n') {
				insertSeparate = true;
			} else {
				if (insertSeparate) {
					sb.append(' ');
					insertSeparate = false;
				}
				sb.append(c);
			}
		}
	}

	@SuppressWarnings("unchecked")
	public void replace(String xpath, String content, TagScope scope) {
		Object[] elements = findAll(xpath);
		if (elements != null && elements.length > 0) {
			TagNode tagNode = null;
			// find the first TagNode
			int i = 0;
			for (; i < elements.length; i++) {
				if (elements[i] instanceof TagNode) {
					tagNode = (TagNode) elements[i];
					break;
				}
			}
			// remove all the following element
			for (i++; i < elements.length; i++) {
				if (elements[i] instanceof TagNode) {
					TagNode theNode = (TagNode) elements[i];
					theNode.getParent().removeChild(theNode);
				}
			}
			
			// update the found tagNode
			if (tagNode != null) {
				NodeWrapper wrapper;
				if (tagNode instanceof NodeWrapper) {
					wrapper = (NodeWrapper) tagNode;
				} else {
					wrapper = new NodeWrapper(tagNode.getName(), tagNode);
					List children = tagNode.getParent().getChildren();
					int index = children.indexOf(tagNode);
					children.set(index, wrapper);
				}
				
				switch (scope) {
				case OPEN:
					wrapper.openText = content;
					break;
				case CLOSE:
					wrapper.closeText = content;
					break;
				case INNER:
					wrapper.innerText = content;
					break;
				case ALL:
					wrapper.openText = content;
					wrapper.innerText = "";
					wrapper.closeText = "";
					break;
				}
			}
		} else {
			logger.error("Can't find node:" + xpath);
		}
	}

	public String getInnerHtml(TagNode tagNode) {
		return cleaner.getInnerHtml(tagNode);
	}

	public String toString() {
		StringWriter writer = new StringWriter();
		//TagNode header = htmlNode.getElementsByName("head", true)[0];
		TagNode body = htmlNode.getElementsByName("body", true)[0];
		try {
			// skip the header context
			//serializer.serializeChildNodes(header, writer);
			serializer.serializeChildNodes(body, writer);
		} catch (IOException ex) {
			logger.error(ex);
		}
		return writer.toString();
	}
	
	class NodeWrapper extends TagNode {
		private String openText;
		private String closeText;
		private String innerText;
		private TagNode node;
		
		public NodeWrapper(String name, TagNode node) {
			super(name);
			this.node = node;
			super.getChildren().addAll(node.getChildren());
		}
		
		public List getChildren() {
			if (innerText == null) {
				return node.getChildren();
			} else {
				return BlankObject.List;
			}
		}
		
		public void serialize(XmlSerializer xmlSerializer, Writer writer) throws IOException {
			PartXmlSerializer partXmlSerializer = ((PartXmlSerializer)xmlSerializer);
	    	if (openText != null) {
	    		writer.write(openText);
	    	} else {
	    		partXmlSerializer.serializeOpenTag(node, writer, false);
	    	}
	    	if (innerText != null) {
	    		writer.write(innerText);
	    	} else {
	    		if (!partXmlSerializer.isMinimizedTagSyntax(node)) {
	    			partXmlSerializer.serializeChildNodes(node, writer);
	    		}
	    	}
	    	if (closeText != null) {
	    		writer.write(closeText);
	    	} else {
	    		if (!partXmlSerializer.isMinimizedTagSyntax(node)) {
	    			partXmlSerializer.serializeEndTag(node, writer, false);
	    		}
	    	}
	    }
	}

	class PartXmlSerializer extends XmlSerializer {

		public PartXmlSerializer(CleanerProperties props) {
			super(props);
		}
		
		@Override
		public void serializeOpenTag(TagNode tagNode, Writer writer, boolean newLine) throws IOException {
			super.serializeOpenTag(tagNode, writer, newLine);
		}
		
		@Override
		public void serializeEndTag(TagNode tagNode, Writer writer, boolean newLine) throws IOException {
			super.serializeEndTag(tagNode, writer, newLine);
		}
		
		@Override
		public boolean isMinimizedTagSyntax(TagNode tagNode) {
			return super.isMinimizedTagSyntax(tagNode);
		}

		public void serializeChildNodes(TagNode tagNode, Writer writer) throws IOException {
			List tagChildren = tagNode.getChildren();
			Iterator childrenIt = tagChildren.iterator();
			while (childrenIt.hasNext()) {
				Object item = childrenIt.next();
				if (item != null) {
					if (item instanceof ContentNode) {
						String content = ((ContentNode) item).getContent().toString();
						// Don't change the original content
						writer.write(content);
					} else {
						((BaseToken) item).serialize(this, writer);
					}
				}
			}
		}

		@Override
		protected void serialize(TagNode tagNode, Writer writer)
				throws IOException {
			serializeOpenTag(tagNode, writer, false);
			
			if (!isMinimizedTagSyntax(tagNode)) {
				serializeChildNodes(tagNode, writer);
				
				serializeEndTag(tagNode, writer, false);
			}
		}
	}
}